Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\daemon\mod.rs
Line
Count
Source
1
//! Daemon implementation
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
7
use std::collections::HashMap;
8
use std::{
9
    io,
10
    sync::{Arc, Mutex},
11
    time::Duration,
12
};
13
use std::{thread, time};
14
15
use crate::get_console_window_handle;
16
use crate::utils::config::{Cluster, DaemonConfig, EdgeBehavior};
17
use crate::utils::debug::StringRepr;
18
use crate::utils::windows::{clear_screen, set_console_color, WindowsApi};
19
use cssh_rs_meta::PACKAGE_NAME;
20
21
use crate::{
22
    current_exe_path, spawn_console_process,
23
    utils::{
24
        constants::PIPE_NAME,
25
        windows::{
26
            arrange_console, get_console_input_buffer, read_keyboard_input,
27
            set_console_border_color,
28
        },
29
    },
30
    WindowsSettingsDefaultTerminalApplicationGuard,
31
};
32
use bracoxide::explode;
33
use cssh_rs_protocol::{
34
    deserialization::deserialize_pid,
35
    serialization::{serialize_client_state, serialize_highlight, serialize_input_record_0},
36
    ClientState, FRAMED_HIGHLIGHT_LENGTH, FRAMED_INPUT_RECORD_LENGTH, FRAMED_STATE_CHANGE_LENGTH,
37
    SERIALIZED_INPUT_RECORD_0_LENGTH, SERIALIZED_PID_LENGTH, TAG_HIGHLIGHT, TAG_INPUT_RECORD,
38
    TAG_KEEP_ALIVE, TAG_STATE_CHANGE,
39
};
40
use log::{debug, error, warn};
41
use tokio::{
42
    net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions},
43
    sync::{
44
        broadcast::{self, error::RecvError, Receiver, Sender},
45
        watch,
46
    },
47
    task::JoinHandle,
48
};
49
use windows::Win32::System::Console::{
50
    CONSOLE_CHARACTER_ATTRIBUTES, INPUT_RECORD_0, KEY_EVENT_RECORD, LEFT_ALT_PRESSED,
51
    LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SHIFT_PRESSED,
52
};
53
54
use windows::Win32::UI::Input::KeyboardAndMouse::{
55
    VIRTUAL_KEY, VK_A, VK_C, VK_D, VK_DOWN, VK_E, VK_ESCAPE, VK_H, VK_J, VK_K, VK_L, VK_LEFT, VK_N,
56
    VK_R, VK_RIGHT, VK_T, VK_UP,
57
};
58
use windows::Win32::UI::WindowsAndMessaging::{SW_RESTORE, SW_SHOWMINIMIZED, SW_SHOWNOACTIVATE};
59
use windows::Win32::{
60
    Foundation::{COLORREF, HANDLE, HWND, STILL_ACTIVE},
61
    System::{Console::ENABLE_PROCESSED_INPUT, Threading::PROCESS_QUERY_INFORMATION},
62
};
63
64
use self::grid::{grid_dimensions, ClientGrid};
65
use self::workspace::WorkspaceArea;
66
67
mod grid;
68
mod workspace;
69
70
/// The capacity of the broadcast channel used
71
/// to send the input records read from the console input buffer
72
/// to the named pipe servers connected to each client in parallel.
73
const SENDER_CAPACITY: usize = 1024 * 1024;
74
75
/// Bits in `KEY_EVENT_RECORD::dwControlKeyState` that represent
76
/// "real" modifier keys (Ctrl / Alt / Shift) as opposed to lock
77
/// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) or the
78
/// `ENHANCED_KEY` flag.
79
///
80
/// Control-mode key classification ANDs `dwControlKeyState` with
81
/// this mask before matching; otherwise an enabled CapsLock or
82
/// NumLock would make `dwControlKeyState` non-zero and silently
83
/// skip every `(VK_*, 0)` arm.
84
const MODIFIER_MASK: u32 =
85
    LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED | LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED | SHIFT_PRESSED;
86
87
/// Top-level control-mode action a keystroke classifies into.
88
///
89
/// Extracted from [`Daemon::handle_input_record`]'s dispatch match
90
/// so the classification - including the [`MODIFIER_MASK`] step -
91
/// can be regression tested without instantiating a full
92
/// [`Daemon`].
93
#[derive(Debug, PartialEq, Eq)]
94
enum ControlModeAction {
95
    /// `[r]` - rearrange every client window.
96
    Retile,
97
    /// `[e]` - open the enable/disable input submenu.
98
    OpenEnableDisableSubmenu,
99
    /// `[t]` - flip each client's [`ClientState`].
100
    ToggleEnabled,
101
    /// `[n]` - force every client back to [`ClientState::Active`].
102
    EnableAll,
103
    /// `[c]` - prompt for new hostnames and launch additional clients.
104
    CreateWindows,
105
    /// `[h]` - copy the active clients' hostnames to the clipboard.
106
    CopyHostnames,
107
    /// Any other key in the active control-mode prompt.
108
    NoOp,
109
}
110
111
/// Enable/disable-submenu action a keystroke classifies into.
112
///
113
/// Extracted from [`Daemon::handle_enable_disable_submenu_key`]'s
114
/// dispatch match for the same reason as [`ControlModeAction`].
115
#[derive(Debug, PartialEq, Eq)]
116
enum EnableDisableSubmenuAction {
117
    /// `[e]` - force the targeted client(s) to [`ClientState::Active`].
118
    Enable,
119
    /// `[d]` - force the targeted client(s) to [`ClientState::Disabled`].
120
    Disable,
121
    /// `[t]` - flip the targeted client(s)' [`ClientState`].
122
    Toggle,
123
    /// Arrow key or vim motion - move the submenu's selection cursor.
124
    Navigate(NavigationDirection),
125
    /// Any other key while the submenu is open.
126
    NoOp,
127
}
128
129
/// Direction of a navigation keystroke inside the enable/disable
130
/// submenu.
131
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
132
enum NavigationDirection {
133
    Up,
134
    Down,
135
    Left,
136
    Right,
137
}
138
139
/// Classifies a top-level control-mode keystroke.
140
///
141
/// `control_key_state` is ANDed with [`MODIFIER_MASK`] so lock
142
/// toggles (`CAPSLOCK_ON`, `NUMLOCK_ON`, `SCROLLLOCK_ON`) and the
143
/// `ENHANCED_KEY` flag never bleed into the match - the
144
/// `(VK_*, 0)` arms must still fire while any of those bits are
145
/// set. Any "real" modifier bit (Ctrl / Alt / Shift) survives the
146
/// mask and falls through to [`ControlModeAction::NoOp`].
147
///
148
/// # Arguments
149
///
150
/// * `virtual_key`       - The pressed key's [`VIRTUAL_KEY`].
151
/// * `control_key_state` - The raw `dwControlKeyState` field from
152
///                         the [`KEY_EVENT_RECORD`].
153
///
154
/// # Returns
155
///
156
/// The [`ControlModeAction`] the dispatch should execute.
157
78
fn classify_control_mode_key(
158
78
    virtual_key: VIRTUAL_KEY,
159
78
    control_key_state: u32,
160
78
) -> ControlModeAction {
161
78
    return match (virtual_key, control_key_state & MODIFIER_MASK) {
162
6
        (VK_R, 0) => ControlModeAction::Retile,
163
6
        (VK_E, 0) => ControlModeAction::OpenEnableDisableSubmenu,
164
6
        (VK_T, 0) => ControlModeAction::ToggleEnabled,
165
6
        (VK_N, 0) => ControlModeAction::EnableAll,
166
6
        (VK_C, 0) => ControlModeAction::CreateWindows,
167
6
        (VK_H, 0) => ControlModeAction::CopyHostnames,
168
42
        _ => ControlModeAction::NoOp,
169
    };
170
78
}
171
172
/// Classifies an enable/disable-submenu keystroke.
173
///
174
/// See [`classify_control_mode_key`] for the [`MODIFIER_MASK`]
175
/// rationale; the same lock-state / `ENHANCED_KEY` masking applies
176
/// to the submenu so its `[e]`, `[d]`, `[t]` bindings keep working
177
/// regardless of lock state.
178
///
179
/// # Arguments
180
///
181
/// * `virtual_key`       - The pressed key's [`VIRTUAL_KEY`].
182
/// * `control_key_state` - The raw `dwControlKeyState` field from
183
///                         the [`KEY_EVENT_RECORD`].
184
///
185
/// # Returns
186
///
187
/// The [`EnableDisableSubmenuAction`] the dispatch should execute.
188
154
fn classify_enable_disable_submenu_key(
189
154
    virtual_key: VIRTUAL_KEY,
190
154
    control_key_state: u32,
191
154
) -> EnableDisableSubmenuAction {
192
154
    return match (virtual_key, control_key_state & MODIFIER_MASK) {
193
10
        (VK_E, 0) => EnableDisableSubmenuAction::Enable,
194
7
        (VK_D, 0) => EnableDisableSubmenuAction::Disable,
195
8
        (VK_T, 0) => EnableDisableSubmenuAction::Toggle,
196
13
        (VK_UP, 0) | (VK_K, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Up),
197
14
        (VK_DOWN, 0) | (VK_J, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Down),
198
12
        (VK_LEFT, 0) | (VK_H, 0) => EnableDisableSubmenuAction::Navigate(NavigationDirection::Left),
199
        (VK_RIGHT, 0) | (VK_L, 0) => {
200
12
            EnableDisableSubmenuAction::Navigate(NavigationDirection::Right)
201
        }
202
78
        _ => EnableDisableSubmenuAction::NoOp,
203
    };
204
154
}
205
206
/// Representation of a client
207
#[derive(Clone)]
208
struct Client {
209
    /// Hostname the client is connect to (or supposed to connect to).
210
    hostname: String,
211
    /// Window handle to the clients console window.
212
    window_handle: HWND,
213
    /// Process handle to the client process.
214
    process_handle: HANDLE,
215
    /// Process id of the client process.
216
    ///
217
    /// Used by the pipe server task to correlate which client has connected
218
    /// to it, via a handshake over the named pipe.
219
    process_id: u32,
220
    /// Authoritative source for this client's [`ClientState`].
221
    ///
222
    /// The daemon broadcasts new state values through the [`watch::Sender`];
223
    /// the assigned pipe-server task subscribes upon successful PID
224
    /// correlation and forwards every change to the client over the named
225
    /// pipe. [`watch::Sender`] is itself [`Clone`], so cloning a [`Client`]
226
    /// produces another sender that drives the same channel.
227
    state_sender: watch::Sender<ClientState>,
228
    /// Authoritative source for this client's highlight flag, set while
229
    /// the client is the daemon's currently selected submenu client.
230
    /// Visual only; input gating uses [`Client::state_sender`].
231
    highlight_sender: watch::Sender<bool>,
232
    /// Index passed to [`arrange_client_window`] when this client's
233
    /// on-screen position was last computed. Survives
234
    /// [`Clients::retain`] so the submenu navigation grid keeps
235
    /// matching the visible layout until the next retile.
236
    tile_index: usize,
237
}
238
239
unsafe impl Send for Client {}
240
241
/// Collection of [`Client`]s maintaining insertion order and a PID-indexed
242
/// lookup table.
243
///
244
/// The ordered list preserves client window placement semantics, while the
245
/// index enables O(1) lookup by process id - required by the pipe server task
246
/// during PID correlation and future per-client pipe server control.
247
struct Clients {
248
    /// Ordered list of clients; order matches launch order and is used for
249
    /// window arrangement and z-order synchronization.
250
    list: Vec<Client>,
251
    /// Maps a client's process id to its index in [`list`](Clients::list).
252
    pid_index: HashMap<u32, usize>,
253
    /// `number_of_consoles` value the current on-screen layout was
254
    /// computed with. Drives [`grid_dimensions`] for the submenu nav.
255
    /// Updated when the tiler positions windows
256
    /// ([`Clients::reset_tile_layout`]); preserved across
257
    /// [`Clients::retain`] so a closed-but-not-retiled window leaves
258
    /// a visible gap in the grid too.
259
    layout_n: usize,
260
}
261
262
impl Clients {
263
    /// Creates a new empty collection.
264
27
    fn new() -> Self {
265
27
        return Clients {
266
27
            list: Vec::new(),
267
27
            pid_index: HashMap::new(),
268
27
            layout_n: 0,
269
27
        };
270
27
    }
271
272
    /// Appends a client to the collection and records its position in the
273
    /// PID index.
274
    ///
275
    /// # Arguments
276
    ///
277
    /// * `client` - The [`Client`] to add.
278
    ///
279
    /// # Panics
280
    ///
281
    /// Panics if a client with the same process id is already present, as
282
    /// duplicate PIDs indicate broken daemon bookkeeping.
283
59
    fn push(&mut self, mut client: Client) {
284
59
        let index = self.list.len();
285
59
        assert!(
286
59
            !self.pid_index.contains_key(&client.process_id),
287
            "Duplicate client PID {} - daemon bookkeeping broken",
288
            client.process_id,
289
        );
290
        // Push assumes the new client occupies the next cell in a dense
291
        // layout - matching what the tiler does at initial launch and
292
        // right after `[c]reate`. `retain` leaves these values alone so
293
        // closed-but-not-retiled gaps stay visible to the navigation
294
        // grid. The next retile renumbers everything dense again.
295
58
        client.tile_index = index;
296
58
        self.pid_index.insert(client.process_id, index);
297
58
        self.list.push(client);
298
58
        self.layout_n = self.list.len();
299
58
    }
300
301
    /// Reassigns dense [`Client::tile_index`] values to `valid_pids` in
302
    /// the supplied order and snapshots the new [`Clients::layout_n`].
303
    ///
304
    /// Called by [`Daemon::rearrange_client_windows`] right before it
305
    /// re-positions the actual windows on screen, so navigation reads
306
    /// the same layout the tiler just applied.
307
    ///
308
    /// Invariant: `valid_pids` must cover every PID currently tracked
309
    /// in `self.list`. Passing a strict subset (e.g. a liveness-filtered
310
    /// list) would shrink `layout_n` while leaving stale `tile_index >=
311
    /// layout_n` values on the excluded clients, breaking the
312
    /// [`ClientGrid`] built from this collection. Drop dead clients
313
    /// via [`Clients::retain`] before retiling.
314
    ///
315
    /// # Arguments
316
    ///
317
    /// * `valid_pids` - PIDs of the clients that will be tiled, in the
318
    ///                  order they will be passed to
319
    ///                  [`arrange_client_window`].
320
1
    fn reset_tile_layout(&mut self, valid_pids: &[u32]) {
321
1
        debug_assert_eq!(
322
1
            valid_pids.len(),
323
1
            self.list.len(),
324
            "reset_tile_layout must receive every tracked client; \
325
             call Clients::retain to drop dead entries first",
326
        );
327
4
        for (index, pid) in 
valid_pids1
.
iter1
().
enumerate1
() {
328
4
            if let Some(&list_index) = self.pid_index.get(pid) {
329
4
                self.list[list_index].tile_index = index;
330
4
            
}0
331
        }
332
1
        self.layout_n = valid_pids.len();
333
1
    }
334
335
    /// Returns a reference to the client with the given process id, if any.
336
    ///
337
    /// # Arguments
338
    ///
339
    /// * `pid` - The process id of the client to look up.
340
    ///
341
    /// # Returns
342
    ///
343
    /// `Some(&Client)` if a client with the given PID exists, `None` otherwise.
344
39
    fn get_by_pid(&self, pid: u32) -> Option<&Client> {
345
39
        return self
346
39
            .pid_index
347
39
            .get(&pid)
348
39
            .map(|&index| return 
&self.list[index]34
);
349
39
    }
350
351
    /// Retains only the clients for which the predicate returns `true`,
352
    /// rebuilding the PID index to reflect the new positions.
353
    ///
354
    /// # Arguments
355
    ///
356
    /// * `f` - Predicate applied to each [`Client`]; kept when it returns `true`.
357
5
    fn retain<F: FnMut(&Client) -> bool>(&mut self, mut f: F) {
358
17
        
self.list5
.
retain5
(|client| return f(client));
359
5
        self.pid_index.clear();
360
10
        for (index, client) in 
self.list.iter()5
.
enumerate5
() {
361
10
            self.pid_index.insert(client.process_id, index);
362
10
        }
363
5
    }
364
}
365
366
/// Allows treating a [`Clients`] collection as a `&[Client]`, so callers can
367
/// use `&clients` where a slice is expected and get slice methods
368
/// (`iter`, `len`, `is_empty`, ...) via deref coercion.
369
impl std::ops::Deref for Clients {
370
    type Target = [Client];
371
372
46
    fn deref(&self) -> &[Client] {
373
46
        return &self.list;
374
46
    }
375
}
376
377
/// Consumes the collection and yields its clients in insertion order.
378
///
379
/// Used when merging a freshly launched [`Clients`] batch into an existing
380
/// collection while also spawning per-client pipe servers.
381
impl IntoIterator for Clients {
382
    type Item = Client;
383
    type IntoIter = std::vec::IntoIter<Client>;
384
385
0
    fn into_iter(self) -> Self::IntoIter {
386
0
        return self.list.into_iter();
387
0
    }
388
}
389
390
/// Hacky wrapper around a window handle.
391
///
392
/// As we cannot implement foreign traits for foreign structs
393
/// we introduce this wrapper to implement [Send] for [HWND].
394
#[derive(Debug, Eq)]
395
struct HWNDWrapper {
396
    hwdn: HWND,
397
}
398
399
unsafe impl Send for HWNDWrapper {}
400
401
impl PartialEq for HWNDWrapper {
402
    /// Returns whether to `HWNDWrapper` instances are equal or not
403
    /// based on the [HWND] they wrap.
404
2
    fn eq(&self, other: &Self) -> bool {
405
2
        return self.hwdn == other.hwdn;
406
2
    }
407
}
408
409
/// Returns a window handle to the current console window.
410
///
411
/// The [HWND] is wrapped in a `HWNDWrapper` so that
412
/// we can pass it inbetween threads.
413
0
fn get_console_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper {
414
0
    return HWNDWrapper {
415
0
        hwdn: api.get_console_window(),
416
0
    };
417
0
}
418
419
/// Returns a window handle to the foreground window.
420
///
421
/// The [HWND] is wrapped in a `HWNDWrapper` so that
422
/// we can pass it inbetween threads.
423
0
fn get_foreground_window_wrapper(api: &dyn WindowsApi) -> HWNDWrapper {
424
0
    return HWNDWrapper {
425
0
        hwdn: api.get_foreground_window(),
426
0
    };
427
0
}
428
429
/// Enum of all possible control mode states.
430
#[derive(PartialEq, Debug)]
431
enum ControlModeState {
432
    /// Controle mode is inactive.
433
    Inactive,
434
    /// One of the keys required for the control mode key combination
435
    /// is currently being pressed.
436
    Initiated,
437
    /// All required keys for the control mode key combination were pressed
438
    /// and control mode is now active.
439
    ///
440
    /// Active control mode prevents any input records from being sent to clients.
441
    Active,
442
    /// The user opened the `[e]nable/disable input` submenu from
443
    /// [`ControlModeState::Active`]; left only via `Esc`, which exits
444
    /// control mode entirely. `highlighted_pid` is the currently selected
445
    /// client (`None` when the cluster is empty); tracking by PID survives
446
    /// background-monitor `retain`s while the submenu is open.
447
    ///
448
    /// `anchor_col` is the upper-grid column carried across vertical
449
    /// moves so a Down + Up roundtrip across the partial-last-row
450
    /// boundary returns to the start cell. `None` while
451
    /// `highlighted_pid` is `None`.
452
    EnableDisableSubmenu {
453
        /// PID of the highlighted client, or `None` for an empty cluster.
454
        highlighted_pid: Option<u32>,
455
        /// Anchor upper-grid column carried across vertical moves.
456
        anchor_col: Option<i32>,
457
    },
458
}
459
460
/// The daemon is responsible to launch a client for
461
/// each host, positioning the client windows, forwarding
462
/// input records to all clients and handling control mode.
463
struct Daemon<'a> {
464
    /// A list of hostnames to connect to.
465
    hosts: Vec<String>,
466
    /// A username to use to connect to all clients.
467
    ///
468
    /// If it is empty the clients will use the SSH config to find an approriate
469
    /// username.
470
    username: Option<String>,
471
    /// Optional port used for all SSH connections.
472
    port: Option<u16>,
473
    /// The `DaemonConfig` that controls how the daemon console window looks like.
474
    config: &'a DaemonConfig,
475
    /// List of available cluster tags
476
    clusters: &'a [Cluster],
477
    /// The current control mode state. The submenu's selected client
478
    /// is carried inline on
479
    /// [`ControlModeState::EnableDisableSubmenu`] - tying its
480
    /// lifetime to the variant guarantees no stale highlight survives
481
    /// after `Esc`.
482
    control_mode_state: ControlModeState,
483
    /// If debug mode is enabled on the daemon it will also be enabled on all
484
    /// clients.
485
    debug: bool,
486
}
487
488
/// Compute the next submenu selection given a grid step.
489
///
490
/// Re-anchors on the first surviving client when `current_pid` is no
491
/// longer present (retained out while the submenu was open).
492
///
493
/// # Arguments
494
///
495
/// * `grid`        - Spatial grid view over the currently tracked clients.
496
/// * `current_pid` - PID currently highlighted, or `None`.
497
/// * `anchor_col`  - Anchor column carried from earlier moves.
498
/// * `direction`   - Direction the navigation keystroke encoded.
499
/// * `edge`        - Behavior when the move would leave the grid.
500
///
501
/// # Returns
502
///
503
/// `(new_pid, new_anchor_col)` to apply, or `(None, None)` for an empty
504
/// cluster.
505
33
fn next_submenu_selection(
506
33
    grid: &ClientGrid,
507
33
    current_pid: Option<u32>,
508
33
    anchor_col: Option<i32>,
509
33
    direction: NavigationDirection,
510
33
    edge: EdgeBehavior,
511
33
) -> (Option<u32>, Option<i32>) {
512
33
    if grid.is_empty() {
513
8
        return (None, None);
514
25
    }
515
25
    let 
current_pid23
= match current_pid.and_then(|pid| return grid.cell(pid)) {
516
23
        Some(cell) => cell.pid,
517
        None => {
518
2
            let first = grid.top_left_pid();
519
2
            let first_anchor = first
520
2
                .and_then(|pid| return grid.cell(pid))
521
2
                .map(|c| return grid.anchor_for(c));
522
2
            return (first, first_anchor);
523
        }
524
    };
525
23
    let anchor = anchor_col.unwrap_or_else(|| 
{0
526
0
        return grid
527
0
            .cell(current_pid)
528
0
            .map(|c| return grid.anchor_for(c))
529
0
            .unwrap_or(0);
530
0
    });
531
23
    return match grid.step(current_pid, anchor, direction, edge) {
532
23
        Some((pid, new_anchor)) => (Some(pid), Some(new_anchor)),
533
0
        None => (Some(current_pid), Some(anchor)),
534
    };
535
33
}
536
537
/// Build a [`ClientGrid`] from `clients` and `workspace_area` using the
538
/// same aspect-ratio expression the tiler uses.
539
///
540
/// # Arguments
541
///
542
/// * `clients`                   - Currently tracked clients in launch order.
543
/// * `workspace_area`            - Available workspace minus the daemon console.
544
/// * `aspect_ratio_adjustment`   - The `aspect_ratio_adjustment` daemon config.
545
///
546
/// # Returns
547
///
548
/// A populated [`ClientGrid`].
549
3
fn build_client_grid(
550
3
    clients: &Clients,
551
3
    workspace_area: &workspace::WorkspaceArea,
552
3
    aspect_ratio_adjustment: f64,
553
3
) -> ClientGrid {
554
3
    let aspect = workspace_aspect_ratio(workspace_area);
555
3
    let layout_n = clients.layout_n as i32;
556
3
    let (cols, rows) = grid_dimensions(layout_n, aspect, aspect_ratio_adjustment);
557
3
    let cells: Vec<(u32, usize)> = clients
558
3
        .iter()
559
7
        .
map3
(|c| return (c.process_id, c.tile_index))
560
3
        .collect();
561
3
    return ClientGrid::from_tiled_pids(&cells, layout_n, cols, rows);
562
3
}
563
564
impl<'a> Daemon<'a> {
565
    /// Builds a minimal [`Daemon`] suitable for unit tests.
566
    ///
567
    /// Populates every field with defaults that do not touch the
568
    /// Windows API or the network. Tests pick the
569
    /// [`ControlModeState`] they need to exercise; everything else
570
    /// stays inert.
571
    #[cfg(test)]
572
16
    fn for_test(
573
16
        config: &'a DaemonConfig,
574
16
        clusters: &'a [Cluster],
575
16
        control_mode_state: ControlModeState,
576
16
    ) -> Self {
577
16
        return Self {
578
16
            hosts: Vec::new(),
579
16
            username: None,
580
16
            port: None,
581
16
            config,
582
16
            clusters,
583
16
            control_mode_state,
584
16
            debug: false,
585
16
        };
586
16
    }
587
588
    /// Launches all client windows and blocks on the main run loop.
589
    ///
590
    /// Sets up the daemon console by disabling processed input mode and applying
591
    /// the configured colors and dimensions.
592
    /// Once all client windows have successfully started the daemon console window
593
    /// is moved to the foreground and receives focus.
594
0
    async fn launch<W: WindowsApi + Clone + 'static>(mut self, windows_api: &W) {
595
0
        windows_api
596
0
            .set_console_title(format!("{PACKAGE_NAME} daemon").as_str())
597
0
            .unwrap();
598
0
        set_console_color(
599
0
            windows_api,
600
0
            CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color),
601
        );
602
0
        set_console_border_color(windows_api, COLORREF(0x000000FF));
603
604
0
        toggle_processed_input_mode(windows_api); // Disable processed input mode
605
606
0
        let workspace_area = workspace::get_workspace_area(windows_api, self.config.height);
607
608
0
        self.arrange_daemon_console(windows_api, &workspace_area);
609
610
        // Looks like on windows 10 re-arranging the console resets the console output buffer
611
0
        set_console_color(
612
0
            windows_api,
613
0
            CONSOLE_CHARACTER_ATTRIBUTES(self.config.console_color),
614
        );
615
616
0
        let mut clients = Arc::new(Mutex::new(
617
0
            launch_clients(
618
0
                windows_api,
619
0
                self.hosts.to_vec(),
620
0
                &self.username,
621
0
                self.port,
622
0
                self.debug,
623
0
                &workspace_area,
624
0
                self.config.aspect_ratio_adjustment,
625
0
                0,
626
0
            )
627
0
            .await,
628
        ));
629
630
        // Now that all clients started, focus the daemon console again.
631
0
        let daemon_console = windows_api.get_console_window();
632
0
        let _ = windows_api.bring_window_to_top(daemon_console, true);
633
634
0
        self.print_instructions(windows_api);
635
0
        self.run(windows_api, &mut clients, &workspace_area).await;
636
0
    }
637
638
    /// The main run loop of the `daemon` subcommand.
639
    ///
640
    /// Opens a multi-producer, multi-consumer broadcasting channel used to
641
    /// send the read input records in parallel to the name pipe servers
642
    /// the clients are listening on.
643
    /// Spawns a background thread that waits for all clients to terminate
644
    /// and then stops the current process.
645
    /// Spawns a background thread that ensures the z-order of all client
646
    /// windows is in sync with the daemon window.
647
    /// I.e. if the daemon window is focussed, all clients should be moved to the foreground.
648
    ///
649
    /// The main loop consists of waiting for input records to read from the keyboard,
650
    /// sending them to all clients and handling control mode.
651
    ///
652
    /// # Arguments
653
    ///
654
    /// * `windows_api`                     - The Windows API implementation to use
655
    /// * `clients`                         - A thread safe mapping from the number
656
    ///                                       a client console window was launched at
657
    ///                                       in relation to the other client windows
658
    ///                                       and the clients console window handle.
659
    /// * `workspace_area`                  - The available workspace area on the
660
    ///                                       primary monitor minus the space occupied
661
    ///                                       by the daemon console window.
662
0
    async fn run<W: WindowsApi + Clone + 'static>(
663
0
        &mut self,
664
0
        windows_api: &W,
665
0
        clients: &mut Arc<Mutex<Clients>>,
666
0
        workspace_area: &workspace::WorkspaceArea,
667
0
    ) {
668
0
        let (sender, _) =
669
0
            broadcast::channel::<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>(SENDER_CAPACITY);
670
671
0
        let mut servers = Arc::new(Mutex::new(
672
0
            self.launch_named_pipe_servers(&sender, Arc::clone(clients)),
673
        ));
674
675
        // Monitor client processes
676
0
        let clients_clone = Arc::clone(clients);
677
0
        let windows_api_clone = windows_api.clone();
678
0
        tokio::spawn(async move {
679
            loop {
680
0
                clients_clone.lock().unwrap().retain(|client| {
681
0
                    match windows_api_clone.get_exit_code(client.process_handle) {
682
0
                        Ok(exit_code) => return exit_code == STILL_ACTIVE.0 as u32,
683
0
                        Err(_) => return false, // Process handle is invalid, remove client
684
                    }
685
0
                });
686
0
                if clients_clone.lock().unwrap().is_empty() {
687
                    // All clients have exited, exit the daemon as well
688
0
                    std::process::exit(0);
689
0
                }
690
0
                tokio::time::sleep(Duration::from_millis(5)).await;
691
            }
692
        });
693
694
0
        ensure_client_z_order_in_sync_with_daemon(
695
0
            Arc::new(windows_api.clone()),
696
0
            clients.to_owned(),
697
        );
698
699
        loop {
700
0
            self.handle_input_record(
701
0
                windows_api,
702
0
                &sender,
703
0
                read_keyboard_input(windows_api),
704
0
                clients,
705
0
                workspace_area,
706
0
                &mut servers,
707
0
            )
708
0
            .await;
709
        }
710
    }
711
712
    /// Launch a named pipe server for each host in a dedicated thread.
713
    ///
714
    /// # Arguments
715
    ///
716
    /// * `sender` - The sender end of the broadcast channel through which
717
    ///              the main thread will send the input records that are to
718
    ///              be forwarded to the clients.
719
    ///
720
    /// # Returns
721
    ///
722
    /// Returns a list of [JoinHandle]s, one handle for each thread.
723
0
    fn launch_named_pipe_servers(
724
0
        &self,
725
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
726
0
        clients: Arc<Mutex<Clients>>,
727
0
    ) -> Vec<JoinHandle<()>> {
728
0
        let mut servers: Vec<JoinHandle<()>> = Vec::new();
729
0
        for _ in &self.hosts {
730
0
            self.launch_named_pipe_server(&mut servers, sender, Arc::clone(&clients));
731
0
        }
732
0
        return servers;
733
0
    }
734
735
    /// Launch a named pipe server in a dedicated thread.
736
    ///
737
    /// # Arguments
738
    ///
739
    /// * `servers` - A list of [JoinHandle]s to which the join handle for
740
    ///               the new thread will be added.
741
    /// * `sender`  - The sender end of the broadcast channel through which
742
    ///               the main thread will send the input records that are to
743
    ///               be forwarded to the clients.
744
0
    fn launch_named_pipe_server(
745
0
        &self,
746
0
        servers: &mut Vec<JoinHandle<()>>,
747
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
748
0
        clients: Arc<Mutex<Clients>>,
749
0
    ) {
750
0
        let named_pipe_server = ServerOptions::new()
751
0
            .access_inbound(true)
752
0
            .access_outbound(true)
753
0
            .pipe_mode(PipeMode::Message)
754
0
            .create(PIPE_NAME)
755
0
            .unwrap_or_else(|err| {
756
0
                error!("{}", err);
757
0
                panic!("Failed to create named pipe server",)
758
            });
759
0
        let mut receiver = sender.subscribe();
760
0
        servers.push(tokio::spawn(async move {
761
0
            named_pipe_server_routine(named_pipe_server, &mut receiver, clients).await;
762
0
        }));
763
0
    }
764
765
    /// Handle the given input record.
766
    ///
767
    /// Input records are being forwarded to all clients.
768
    /// If a sequence of input records matches the control mode
769
    /// key combination, forwarding is temporarily interrupted,
770
    /// until control mode is exited.
771
    ///
772
    /// # Arguments
773
    ///
774
    /// * `sender`                          - The sender end of the broadcast channel
775
    ///                                       through which we will send the input records
776
    ///                                       that are being forwarded to the clients
777
    ///                                       by the named pipe servers (`servers`).
778
    /// * `input_record`                    - The [INPUT_RECORD_0].`KeyEvent` read from the
779
    ///                                       console input buffer.
780
    /// * `clients`                         - A thread safe mapping from the number
781
    ///                                       a client console window was launched at
782
    ///                                       in relation to the other client windows
783
    ///                                       and the clients console window handle.
784
    ///                                       The mapping will be extended if additional clients
785
    ///                                       are being added through control mode `[c]reate window(s)`.
786
    /// * `workspace_area`                  - The available workspace area on the
787
    ///                                       primary monitor minus the space occupied
788
    ///                                       by the daemon console window.
789
    /// * `servers`                         - A thread safe list of [JoinHandle]s,
790
    ///                                       one handle for each named pipe server background thread.
791
    ///                                       The list will be extended if additional clients are being added
792
    ///                                       through control mode `[c]reate window(s)`.
793
0
    async fn handle_input_record<W: WindowsApi + Clone + 'static>(
794
0
        &mut self,
795
0
        windows_api: &W,
796
0
        sender: &Sender<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
797
0
        input_record: INPUT_RECORD_0,
798
0
        clients: &mut Arc<Mutex<Clients>>,
799
0
        workspace_area: &workspace::WorkspaceArea,
800
0
        servers: &mut Arc<Mutex<Vec<JoinHandle<()>>>>,
801
0
    ) {
802
0
        if self.control_mode_is_active(windows_api, clients, input_record) {
803
0
            if self.control_mode_state == ControlModeState::Initiated {
804
0
                clear_screen(windows_api);
805
0
                println!("Control Mode (Esc to exit)");
806
0
                println!(
807
                    "[c]reate window(s), [r]etile, [e]nable/disable input, [t]oggle enabled, e[n]able all, copy active [h]ostname(s)"
808
                );
809
0
                self.control_mode_state = ControlModeState::Active;
810
0
                return;
811
0
            }
812
0
            let key_event = unsafe { input_record.KeyEvent };
813
0
            if !key_event.bKeyDown.as_bool() {
814
0
                return;
815
0
            }
816
0
            if matches!(
817
0
                self.control_mode_state,
818
                ControlModeState::EnableDisableSubmenu { .. }
819
            ) {
820
0
                self.handle_enable_disable_submenu_key(
821
0
                    windows_api,
822
0
                    clients,
823
0
                    workspace_area,
824
0
                    key_event,
825
                );
826
0
                return;
827
0
            }
828
0
            match classify_control_mode_key(
829
0
                VIRTUAL_KEY(key_event.wVirtualKeyCode),
830
0
                key_event.dwControlKeyState,
831
0
            ) {
832
0
                ControlModeAction::Retile => {
833
0
                    self.rearrange_client_windows(
834
0
                        windows_api,
835
0
                        &mut clients.lock().unwrap(),
836
0
                        workspace_area,
837
0
                    );
838
0
                    self.arrange_daemon_console(windows_api, workspace_area);
839
0
                }
840
                ControlModeAction::OpenEnableDisableSubmenu => {
841
0
                    let clients_guard = clients.lock().unwrap();
842
0
                    let grid = build_client_grid(
843
0
                        &clients_guard,
844
0
                        workspace_area,
845
0
                        self.config.aspect_ratio_adjustment,
846
                    );
847
0
                    let next_pid = grid.top_left_pid();
848
0
                    let anchor_col = next_pid
849
0
                        .and_then(|p| return grid.cell(p))
850
0
                        .map(|c| return grid.anchor_for(c));
851
0
                    self.apply_submenu_highlight(&clients_guard, None, next_pid);
852
0
                    self.control_mode_state = ControlModeState::EnableDisableSubmenu {
853
0
                        highlighted_pid: next_pid,
854
0
                        anchor_col,
855
0
                    };
856
0
                    self.render_enable_disable_submenu(windows_api);
857
                }
858
                ControlModeAction::ToggleEnabled => {
859
                    // Snapshot before flipping so each client toggles relative
860
                    // to its own pre-loop state, not to writes this loop has
861
                    // already made.
862
0
                    self.update_client_states(clients, |clients_guard| {
863
0
                        return clients_guard
864
0
                            .iter()
865
0
                            .map(|client| {
866
0
                                let flipped = match *client.state_sender.borrow() {
867
0
                                    ClientState::Active => ClientState::Disabled,
868
0
                                    ClientState::Disabled => ClientState::Active,
869
                                };
870
0
                                return (client.process_id, flipped);
871
0
                            })
872
0
                            .collect();
873
0
                    });
874
0
                    self.quit_control_mode(windows_api);
875
                }
876
                ControlModeAction::EnableAll => {
877
0
                    self.update_client_states(clients, |clients_guard| {
878
0
                        return clients_guard
879
0
                            .iter()
880
0
                            .map(|client| return (client.process_id, ClientState::Active))
881
0
                            .collect();
882
0
                    });
883
0
                    self.quit_control_mode(windows_api);
884
                }
885
                ControlModeAction::CreateWindows => {
886
0
                    clear_screen(windows_api);
887
                    // TODO: make ESC abort
888
0
                    println!("Hostname(s) or cluster tag(s): (leave empty to abort)");
889
0
                    toggle_processed_input_mode(windows_api); // As it was disabled before, this enables it again
890
0
                    let mut hostnames = String::new();
891
0
                    match io::stdin().read_line(&mut hostnames) {
892
0
                        Ok(2) => {
893
0
                            // Empty input (only newline '\n')
894
0
                        }
895
                        Ok(_) => {
896
0
                            let number_of_existing_clients = clients.lock().unwrap().len();
897
0
                            let new_clients = launch_clients(
898
0
                                windows_api,
899
0
                                expand_hosts(
900
0
                                    hostnames.split(' ').map(|x| return x.trim()).collect(),
901
0
                                    self.clusters,
902
                                ),
903
0
                                &self.username,
904
0
                                self.port,
905
0
                                self.debug,
906
0
                                workspace_area,
907
0
                                self.config.aspect_ratio_adjustment,
908
0
                                number_of_existing_clients,
909
                            )
910
0
                            .await;
911
0
                            for client in new_clients.into_iter() {
912
0
                                clients.lock().unwrap().push(client);
913
0
                                self.launch_named_pipe_server(
914
0
                                    &mut servers.lock().unwrap(),
915
0
                                    sender,
916
0
                                    Arc::clone(clients),
917
0
                                );
918
0
                            }
919
                        }
920
0
                        Err(error) => {
921
0
                            error!("{error}");
922
                        }
923
                    }
924
0
                    toggle_processed_input_mode(windows_api); // Re-disable processed input mode.
925
0
                    self.rearrange_client_windows(
926
0
                        windows_api,
927
0
                        &mut clients.lock().unwrap(),
928
0
                        workspace_area,
929
                    );
930
0
                    self.arrange_daemon_console(windows_api, workspace_area);
931
                    // Focus the daemon console again.
932
0
                    let daemon_window = windows_api.get_console_window();
933
0
                    let _ = windows_api.bring_window_to_top(daemon_window, true);
934
0
                    self.quit_control_mode(windows_api);
935
                }
936
                ControlModeAction::CopyHostnames => {
937
0
                    let mut active_hostnames: Vec<String> = vec![];
938
0
                    for client in clients.lock().unwrap().iter() {
939
0
                        if windows_api.is_window(client.window_handle) {
940
0
                            active_hostnames.push(client.hostname.clone());
941
0
                        }
942
                    }
943
0
                    cli_clipboard::set_contents(active_hostnames.join(" ")).unwrap();
944
0
                    self.quit_control_mode(windows_api);
945
                }
946
0
                ControlModeAction::NoOp => {}
947
            }
948
0
            return;
949
0
        }
950
0
        let error_handler = |err| {
951
0
            error!("{}", err);
952
0
            panic!(
953
                "Failed to serialize input recored `{}`",
954
0
                input_record.string_repr()
955
            )
956
        };
957
0
        match sender.send(
958
0
            serialize_input_record_0(&input_record)[..]
959
0
                .try_into()
960
0
                .unwrap_or_else(error_handler),
961
0
        ) {
962
0
            Ok(_) => {}
963
0
            Err(_) => {
964
0
                thread::sleep(time::Duration::from_nanos(1));
965
0
            }
966
        }
967
0
    }
968
969
    /// Updates `self.control_mode_state` for the given input record and
970
    /// reports whether control mode owned the keystroke.
971
    ///
972
    /// Entering control mode requires this function to be called twice
973
    /// because the activating chord `Ctrl + A` produces two input
974
    /// records (the modifier press and the `A` key). Once active, every
975
    /// subsequent key - including the `Esc` that exits control mode -
976
    /// is reported as consumed so callers do not forward it to clients.
977
    ///
978
    /// # Arguments
979
    ///
980
    /// * `windows_api`  - The Windows API implementation to use.
981
    /// * `clients`      - Currently tracked clients. Used to clear the
982
    ///                    submenu highlight on the previously-selected
983
    ///                    client when `Esc` exits the enable/disable
984
    ///                    submenu.
985
    /// * `input_record` - A KeyEvent input record.
986
    ///
987
    /// # Returns
988
    ///
989
    /// Whether the input record was consumed by control mode. Returns
990
    /// `true` while control mode is active (including the `Esc`
991
    /// keystroke that exits it), so callers must not forward such
992
    /// records to clients.
993
1
    fn control_mode_is_active<W: WindowsApi>(
994
1
        &mut self,
995
1
        windows_api: &W,
996
1
        clients: &Mutex<Clients>,
997
1
        input_record: INPUT_RECORD_0,
998
1
    ) -> bool {
999
1
        let key_event = unsafe { input_record.KeyEvent };
1000
1
        if self.control_mode_state == ControlModeState::Active
1001
0
            || matches!(
1002
0
                self.control_mode_state,
1003
                ControlModeState::EnableDisableSubmenu { .. }
1004
            )
1005
        {
1006
1
            if key_event.wVirtualKeyCode == VK_ESCAPE.0 {
1007
                if let ControlModeState::EnableDisableSubmenu {
1008
0
                    highlighted_pid, ..
1009
1
                } = self.control_mode_state
1010
0
                {
1011
0
                    let clients_guard = clients.lock().unwrap();
1012
0
                    self.apply_submenu_highlight(&clients_guard, highlighted_pid, None);
1013
1
                }
1014
1
                self.quit_control_mode(windows_api);
1015
1
                return true;
1016
0
            }
1017
0
            return true;
1018
0
        }
1019
0
        if (key_event.dwControlKeyState & LEFT_CTRL_PRESSED >= 1
1020
0
            || key_event.dwControlKeyState & RIGHT_CTRL_PRESSED >= 1)
1021
0
            && key_event.wVirtualKeyCode == VK_A.0
1022
        {
1023
0
            self.control_mode_state = ControlModeState::Initiated;
1024
0
            return true;
1025
0
        }
1026
0
        return false;
1027
1
    }
1028
1029
    /// Prints the default daemon instructions to the daemon console.
1030
    ///
1031
    /// # Arguments
1032
    ///
1033
    /// * `windows_api` - Windows API used to clear and redraw the
1034
    ///                   daemon console.
1035
2
    fn quit_control_mode<W: WindowsApi>(&mut self, windows_api: &W) {
1036
2
        self.print_instructions(windows_api);
1037
2
        self.control_mode_state = ControlModeState::Inactive;
1038
2
    }
1039
1040
    /// Clears the console screen and prints the default daemon instructions.
1041
2
    fn print_instructions<W: WindowsApi>(&self, windows_api: &W) {
1042
2
        clear_screen(windows_api);
1043
2
        println!("Input to terminal: (Ctrl-A to enter control mode)");
1044
2
    }
1045
1046
    /// Iterates over all still open client windows and re-arranges them
1047
    /// on the screen based on the aspect ration adjustment daemon configuration.
1048
    ///
1049
    /// Client windows will be re-sized and re-positioned.
1050
    ///
1051
    /// # Arguments
1052
    ///
1053
    /// * `windows_api`                     - The Windows API implementation to use
1054
    /// * `clients`                         - A thread safe mapping from the number
1055
    ///                                       a client console window was launched at
1056
    ///                                       in relation to the other client windows
1057
    ///                                       and the clients console window handle.
1058
    ///                                       The number is relevant to determine the
1059
    ///                                       position on the screen the window should
1060
    ///                                       be placed at.
1061
    /// * `workspace_area`                  - The available workspace area on the
1062
    ///                                       primary monitor minus the space occupied
1063
    ///                                       by the daemon console window.
1064
0
    fn rearrange_client_windows<W: WindowsApi>(
1065
0
        &self,
1066
0
        windows_api: &W,
1067
0
        clients: &mut Clients,
1068
0
        workspace_area: &workspace::WorkspaceArea,
1069
0
    ) {
1070
0
        clients.retain(|client| {
1071
0
            let exit_code = match windows_api.get_exit_code(client.process_handle) {
1072
0
                Ok(code) => code,
1073
0
                Err(_) => return false,
1074
            };
1075
0
            return exit_code == STILL_ACTIVE.0 as u32
1076
0
                && windows_api.is_window(client.window_handle);
1077
0
        });
1078
0
        let valid_layout: Vec<(u32, HWND)> = clients
1079
0
            .iter()
1080
0
            .map(|c| return (c.process_id, c.window_handle))
1081
0
            .collect();
1082
0
        let valid_pids: Vec<u32> = valid_layout.iter().map(|(pid, _)| return *pid).collect();
1083
0
        clients.reset_tile_layout(&valid_pids);
1084
0
        for (index, (_, window_handle)) in valid_layout.iter().enumerate() {
1085
0
            arrange_client_window(
1086
0
                windows_api,
1087
0
                window_handle,
1088
0
                workspace_area,
1089
0
                index,
1090
0
                valid_layout.len(),
1091
0
                self.config.aspect_ratio_adjustment,
1092
            )
1093
        }
1094
0
    }
1095
1096
    /// Dispatches a key press received while the daemon is in the
1097
    /// [`ControlModeState::EnableDisableSubmenu`] state. `[e]/[d]/[t]`
1098
    /// act on the currently selected client; `Navigate` moves the
1099
    /// selection and redraws the prompt. The submenu is left via
1100
    /// `ESC`, which is handled by the caller.
1101
    ///
1102
    /// # Arguments
1103
    ///
1104
    /// * `windows_api` - Windows API implementation used by the
1105
    ///                   render helper when redrawing after navigation.
1106
    /// * `clients`     - Shared client collection. Empty lists are a
1107
    ///                   no-op for every action.
1108
    /// * `key_event`   - The key-down [`KEY_EVENT_RECORD`] dispatched
1109
    ///                   from `handle_input_record`.
1110
11
    fn handle_enable_disable_submenu_key<W: WindowsApi>(
1111
11
        &mut self,
1112
11
        windows_api: &W,
1113
11
        clients: &Mutex<Clients>,
1114
11
        workspace_area: &workspace::WorkspaceArea,
1115
11
        key_event: KEY_EVENT_RECORD,
1116
11
    ) {
1117
        let ControlModeState::EnableDisableSubmenu {
1118
11
            highlighted_pid,
1119
11
            anchor_col,
1120
11
        } = self.control_mode_state
1121
        else {
1122
0
            return;
1123
        };
1124
11
        match classify_enable_disable_submenu_key(
1125
11
            VIRTUAL_KEY(key_event.wVirtualKeyCode),
1126
11
            key_event.dwControlKeyState,
1127
11
        ) {
1128
            EnableDisableSubmenuAction::Enable => {
1129
4
                self.update_client_states(clients, |clients_guard| {
1130
4
                    return highlighted_pid
1131
4
                        .and_then(|pid| return 
clients_guard3
.
get_by_pid3
(
pid3
))
1132
4
                        .map(|client| return 
vec!3
[
(client.process_id, ClientState::Active)3
])
1133
4
                        .unwrap_or_default();
1134
4
                });
1135
            }
1136
            EnableDisableSubmenuAction::Disable => {
1137
1
                self.update_client_states(clients, |clients_guard| {
1138
1
                    return highlighted_pid
1139
1
                        .and_then(|pid| return clients_guard.get_by_pid(pid))
1140
1
                        .map(|client| return vec![(client.process_id, ClientState::Disabled)])
1141
1
                        .unwrap_or_default();
1142
1
                });
1143
            }
1144
            EnableDisableSubmenuAction::Toggle => {
1145
2
                self.update_client_states(clients, |clients_guard| {
1146
2
                    return highlighted_pid
1147
2
                        .and_then(|pid| return clients_guard.get_by_pid(pid))
1148
2
                        .map(|client| {
1149
2
                            let flipped = match *client.state_sender.borrow() {
1150
1
                                ClientState::Active => ClientState::Disabled,
1151
1
                                ClientState::Disabled => ClientState::Active,
1152
                            };
1153
2
                            return vec![(client.process_id, flipped)];
1154
2
                        })
1155
2
                        .unwrap_or_default();
1156
2
                });
1157
            }
1158
3
            EnableDisableSubmenuAction::Navigate(direction) => {
1159
3
                let clients_guard = clients.lock().unwrap();
1160
3
                let grid = build_client_grid(
1161
3
                    &clients_guard,
1162
3
                    workspace_area,
1163
3
                    self.config.aspect_ratio_adjustment,
1164
3
                );
1165
3
                let (next_pid, next_anchor) = next_submenu_selection(
1166
3
                    &grid,
1167
3
                    highlighted_pid,
1168
3
                    anchor_col,
1169
3
                    direction,
1170
3
                    self.config.submenu_edge_behavior,
1171
3
                );
1172
3
                self.apply_submenu_highlight(&clients_guard, highlighted_pid, next_pid);
1173
3
                self.control_mode_state = ControlModeState::EnableDisableSubmenu {
1174
3
                    highlighted_pid: next_pid,
1175
3
                    anchor_col: next_anchor,
1176
3
                };
1177
3
                self.render_enable_disable_submenu(windows_api);
1178
3
            }
1179
1
            EnableDisableSubmenuAction::NoOp => {}
1180
        }
1181
11
    }
1182
1183
    /// Redraws the enable/disable submenu prompt.
1184
    ///
1185
    /// # Arguments
1186
    ///
1187
    /// * `windows_api` - Windows API used to clear the console.
1188
3
    fn render_enable_disable_submenu<W: WindowsApi>(&self, windows_api: &W) {
1189
3
        clear_screen(windows_api);
1190
3
        println!("Enable/Disable input (Esc to exit)");
1191
3
        println!("[e]nable, [d]isable, [t]oggle, arrows/hjkl to move");
1192
3
    }
1193
1194
    /// Move the per-client highlight from `prev_pid` to `next_pid`.
1195
    ///
1196
    /// PID-based clearing tolerates the background monitor's `retain`
1197
    /// shifting indices while the submenu is open.
1198
    ///
1199
    /// # Arguments
1200
    ///
1201
    /// * `clients`  - Currently tracked clients.
1202
    /// * `prev_pid` - PID currently highlighted, or `None` if no
1203
    ///                client is highlighted.
1204
    /// * `next_pid` - PID to highlight now, or `None` to clear the
1205
    ///                highlight entirely.
1206
7
    fn apply_submenu_highlight(
1207
7
        &self,
1208
7
        clients: &Clients,
1209
7
        prev_pid: Option<u32>,
1210
7
        next_pid: Option<u32>,
1211
7
    ) {
1212
7
        if let Some(
prev_pid6
) = prev_pid {
1213
6
            if Some(prev_pid) != next_pid {
1214
6
                if let Some(
prev_client4
) = clients.get_by_pid(prev_pid) {
1215
4
                    prev_client.highlight_sender.send_replace(false);
1216
4
                
}2
1217
0
            }
1218
1
        }
1219
7
        if let Some(
next_pid6
) = next_pid {
1220
6
            if let Some(client) = clients.get_by_pid(next_pid) {
1221
6
                client.highlight_sender.send_replace(true);
1222
6
            
}0
1223
1
        }
1224
7
    }
1225
1226
    /// Apply a batch of [`ClientState`] updates while holding the
1227
    /// [`Clients`] mutex exactly once.
1228
    ///
1229
    /// `f` is called with the locked guard and returns the list of
1230
    /// `(pid, new_state)` updates to apply. The guard is held across both
1231
    /// the build and the apply phase so callers see a stable snapshot.
1232
    ///
1233
    /// # Arguments
1234
    ///
1235
    /// * `clients` - Shared client collection.
1236
    /// * `f`       - Builds the updates from a `&Clients` snapshot.
1237
7
    fn update_client_states<F>(&self, clients: &Mutex<Clients>, f: F)
1238
7
    where
1239
7
        F: FnOnce(&Clients) -> Vec<(u32, ClientState)>,
1240
    {
1241
7
        let clients_guard = clients.lock().unwrap();
1242
7
        let updates = f(&clients_guard);
1243
7
        for (
pid6
,
state6
) in updates {
1244
6
            self.set_client_state(&clients_guard, pid, state);
1245
6
        }
1246
7
    }
1247
1248
    /// Push a new [`ClientState`] for the client identified by `pid`.
1249
    ///
1250
    /// Looks the client up by PID and broadcasts the new state through its
1251
    /// [`watch::Sender`]. The pipe-server task subscribed to that sender
1252
    /// observes the change and forwards a [`cssh_rs_protocol::TAG_STATE_CHANGE`]
1253
    /// frame to the client over the named pipe. Called from the
1254
    /// control-mode handlers for `[t]oggle enabled` and `e[n]able all` via
1255
    /// [`Daemon::update_client_states`].
1256
    ///
1257
    /// # Arguments
1258
    ///
1259
    /// * `clients` - The daemon's tracked clients.
1260
    /// * `pid`     - Process id of the client whose state should change.
1261
    /// * `state`   - The new state to broadcast.
1262
6
    fn set_client_state(&self, clients: &Clients, pid: u32, state: ClientState) {
1263
6
        if let Some(client) = clients.get_by_pid(pid) {
1264
6
            // `send_replace` always updates the stored value (unlike `send`,
1265
6
            // which returns `Err` and leaves the value untouched when there
1266
6
            // are no active receivers). This matters during the brief window
1267
6
            // between [`Client`] construction and the pipe-server task's
1268
6
            // `subscribe()`: any state change pushed in that window must
1269
6
            // still be visible to the next subscriber via `borrow`.
1270
6
            client.state_sender.send_replace(state);
1271
6
        
}0
1272
6
    }
1273
1274
    /// Re-sizes and re-positions the daemon console window on the screen
1275
    /// based on the daemon height configuration.
1276
    ///
1277
    /// # Arguments
1278
    ///
1279
    /// * `windows_api` - The Windows API implementation to use
1280
    /// * `workspace_area` - The available workspace area on the
1281
    ///                      primary monitor minus the space occupied
1282
    ///                      by the daemon console window.
1283
0
    fn arrange_daemon_console<W: WindowsApi>(
1284
0
        &self,
1285
0
        windows_api: &W,
1286
0
        workspace_area: &WorkspaceArea,
1287
0
    ) {
1288
0
        let (x, y, width, height) = get_console_rect(
1289
0
            0,
1290
0
            workspace_area.height,
1291
0
            workspace_area.width - (workspace_area.x_fixed_frame + workspace_area.x_size_frame),
1292
0
            self.config.height,
1293
0
            workspace_area,
1294
0
        );
1295
0
        arrange_console(windows_api, x, y, width, height);
1296
0
    }
1297
}
1298
1299
/// The processed console input mode controls whether special key combinations
1300
/// such as `Ctrl + c` or `Ctrl + BREAK` receive special handling or are treated
1301
/// as simple key presses.
1302
///
1303
/// By default processed input mode is enabled, meaning `Ctrl + c` is treated as
1304
/// a signal, not key presses.
1305
///
1306
/// <https://learn.microsoft.com/en-us/windows/console/ctrl-c-and-ctrl-break-signals>
1307
///
1308
/// # Arguments
1309
///
1310
/// * `windows_api` - The Windows API implementation to use
1311
0
fn toggle_processed_input_mode<W: WindowsApi>(windows_api: &W) {
1312
0
    let handle = get_console_input_buffer();
1313
0
    let mode = windows_api.get_console_mode(handle).unwrap();
1314
0
    let new_mode = windows::Win32::System::Console::CONSOLE_MODE(mode.0 ^ ENABLE_PROCESSED_INPUT.0);
1315
0
    let _ = windows_api.set_console_mode(handle, new_mode);
1316
0
}
1317
1318
/// Resolve cluster tags into hostnames
1319
///
1320
/// Iterates over the list of hosts to find and resolve cluster tags.
1321
/// Nested cluster tags are supported but recursivness is not checked for.
1322
///
1323
/// # Arguments
1324
///
1325
/// * `hosts`       - List of hosts including hostnames and or cluster tags
1326
/// * `clusters`    - List of available cluster tags
1327
///
1328
/// # Returns
1329
///
1330
/// A list of hostnames
1331
18
pub fn resolve_cluster_tags<'a>(hosts: Vec<&'a str>, clusters: &'a [Cluster]) -> Vec<&'a str> {
1332
18
    let mut resolved_hosts: Vec<&str> = Vec::new();
1333
    let mut is_cluster_tag: bool;
1334
31
    for host in 
hosts18
{
1335
31
        is_cluster_tag = false;
1336
31
        for 
cluster23
in clusters {
1337
23
            if host == cluster.name {
1338
5
                is_cluster_tag = true;
1339
5
                resolved_hosts.extend(resolve_cluster_tags(
1340
9
                    
cluster.hosts.iter()5
.
map5
(|host| return &**host).
collect5
(),
1341
5
                    clusters,
1342
                ));
1343
5
                break;
1344
18
            }
1345
        }
1346
31
        if !is_cluster_tag {
1347
26
            resolved_hosts.push(host);
1348
26
        
}5
1349
    }
1350
18
    return resolved_hosts;
1351
18
}
1352
1353
/// Resolve cluster tags in `hosts` and expand brace expressions
1354
/// (e.g. `host{1..3}.local`) in each resulting hostname.
1355
///
1356
/// Used by the control-mode `[c]reate window(s)` path so hostname
1357
/// input behaves the same as on the CLI. Each cluster-resolved
1358
/// hostname is passed through [`bracoxide::explode`] individually;
1359
/// hostnames that do not contain a brace expression are kept as-is.
1360
///
1361
/// # Arguments
1362
///
1363
/// * `hosts`    - User-supplied hostnames and/or cluster tags.
1364
/// * `clusters` - Available cluster definitions.
1365
///
1366
/// # Returns
1367
///
1368
/// The fully resolved, brace-expanded list of hostnames.
1369
4
pub fn expand_hosts(hosts: Vec<&str>, clusters: &[Cluster]) -> Vec<String> {
1370
4
    return resolve_cluster_tags(hosts, clusters)
1371
4
        .into_iter()
1372
7
        .
flat_map4
(|host| return explode(host).unwrap_or_else(|_| return
vec!4
[
host4
.
to_owned4
()]))
1373
4
        .collect();
1374
4
}
1375
1376
/// Launches a client console for each given host and waits for
1377
/// the client windows to exist before returning their handles.
1378
///
1379
/// # Arguments
1380
///
1381
/// * `windows_api`             - The Windows API implementation to use
1382
/// * `hosts`                   - List of hosts
1383
/// * `username`                - Optional username, if none is given
1384
///                               the client will use the SSH config to
1385
///                               determine a username.
1386
/// * `port`                    - Optional port for SSH connections
1387
/// * `debug`                   - Toggles debug mode on the client.
1388
/// * `workspace_area`          - The available workspace area on the primary monitor
1389
///                               minus the space occupied by the daemon console window.
1390
///                               Used to arrange the client window.
1391
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1392
///                               Used to arrange the client window.
1393
/// * `index_offset`            - Offset used to position the new windows correctly
1394
///                               from the start, avoiding flickering.
1395
///
1396
/// # Returns
1397
///
1398
/// A [`Clients`] collection preserving the launch order and indexed by
1399
/// process id for pipe-server correlation.
1400
0
async fn launch_clients<W: WindowsApi + 'static + Clone>(
1401
0
    windows_api: &W,
1402
0
    hosts: Vec<String>,
1403
0
    username: &Option<String>,
1404
0
    port: Option<u16>,
1405
0
    debug: bool,
1406
0
    workspace_area: &workspace::WorkspaceArea,
1407
0
    aspect_ratio_adjustment: f64,
1408
0
    index_offset: usize,
1409
0
) -> Clients {
1410
0
    let len_hosts = hosts.len();
1411
0
    let _guard = WindowsSettingsDefaultTerminalApplicationGuard::new();
1412
1413
    // Create an Arc to share the windows_api across parallel tasks
1414
0
    let windows_api_arc = Arc::new(windows_api.clone());
1415
1416
    // Create tasks for each client launch using spawn_blocking to handle the synchronous operations
1417
0
    let mut tasks = Vec::new();
1418
1419
0
    for (index, host) in hosts.into_iter().enumerate() {
1420
0
        let username_client = username.clone();
1421
0
        let workspace_area_client = *workspace_area;
1422
0
        let windows_api_clone = Arc::clone(&windows_api_arc);
1423
1424
        // Use spawn_blocking to run the synchronous launch_client_console in parallel
1425
0
        let task = tokio::task::spawn_blocking(move || {
1426
0
            let (window_handle, process_handle, process_id) = launch_client_console(
1427
0
                windows_api_clone.as_ref(),
1428
0
                &host,
1429
0
                username_client,
1430
0
                port,
1431
0
                debug,
1432
0
                index + index_offset,
1433
0
                &workspace_area_client,
1434
0
                len_hosts + index_offset,
1435
0
                aspect_ratio_adjustment,
1436
0
            );
1437
            // The receivers are dropped immediately; pipe-server tasks
1438
            // acquire their own receivers via `subscribe()` after PID
1439
            // correlation. Holding the senders on the [`Client`] keeps both
1440
            // channels alive for the lifetime of the client.
1441
0
            let (state_sender, _state_receiver) = watch::channel(ClientState::Active);
1442
0
            let (highlight_sender, _highlight_receiver) = watch::channel(false);
1443
0
            return (
1444
0
                index,
1445
0
                Client {
1446
0
                    hostname: host,
1447
0
                    window_handle,
1448
0
                    process_handle,
1449
0
                    process_id,
1450
0
                    state_sender,
1451
0
                    highlight_sender,
1452
0
                    // Placeholder - `Clients::push` overwrites with the
1453
0
                    // dense `list.len()`-based tile index.
1454
0
                    tile_index: 0,
1455
0
                },
1456
0
            );
1457
0
        });
1458
1459
0
        tasks.push(task);
1460
    }
1461
1462
    // Wait for all tasks to complete in parallel
1463
0
    let mut results = Vec::new();
1464
0
    for task in tasks {
1465
0
        match task.await {
1466
0
            Ok(result) => results.push(result),
1467
0
            Err(e) => panic!("Failed to launch client: {e}"),
1468
        }
1469
    }
1470
1471
    // Sort results by index to maintain order
1472
0
    results.sort_by_key(|(index, _)| return *index);
1473
1474
0
    let mut clients = Clients::new();
1475
0
    for (_, client) in results.into_iter() {
1476
0
        clients.push(client);
1477
0
    }
1478
0
    return clients;
1479
0
}
1480
1481
/// Launchs a `client` console process with its own window with the given
1482
/// CLI arguments/options: `host`, `username`, `port`, `debug`.
1483
///
1484
/// Waits for the window to open, then re-arranges it based on
1485
/// the total number of clients, the size of the daemon console window and
1486
/// its index relative to the other client windows.
1487
///
1488
/// # Arguments
1489
///
1490
/// * `windows_api`             - The Windows API implementation to use
1491
/// * `host`                    - Hostname the client should connect to
1492
/// * `username`                - Username the client should use
1493
/// * `port`                    - Optional port for SSH connections
1494
/// * `debug`                   - Toggle debug mode on the client
1495
/// * `index`                   - The index of the client in the list of all clients.
1496
///                               Used to re-arrange the client window.
1497
/// * `workspace_area`          - The available workspace area on the primary monitor
1498
///                               minus the space occupied by the daemon console window.
1499
/// * `number_of_consoles`      - The total number of active client console windows.
1500
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1501
///
1502
/// # Returns
1503
///
1504
/// A tuple containing the window handle, process handle, and process id of the
1505
/// client process.
1506
0
fn launch_client_console<W: WindowsApi>(
1507
0
    windows_api: &W,
1508
0
    host: &str,
1509
0
    username: Option<String>,
1510
0
    port: Option<u16>,
1511
0
    debug: bool,
1512
0
    index: usize,
1513
0
    workspace_area: &workspace::WorkspaceArea,
1514
0
    number_of_consoles: usize,
1515
0
    aspect_ratio_adjustment: f64,
1516
0
) -> (HWND, HANDLE, u32) {
1517
    // The first argument must be `--` to ensure all following arguments are treated
1518
    // as positional arguments and not as options if they start with `-`.
1519
0
    let mut client_args: Vec<String> = Vec::new();
1520
0
    if debug {
1521
0
        client_args.push("-d".to_string());
1522
0
    }
1523
0
    let mut actual_host = host;
1524
0
    let mut actual_username = username;
1525
0
    if let Some(split_result) = host.split_once("@") {
1526
0
        actual_username = Some(split_result.0.to_owned());
1527
0
        actual_host = split_result.1;
1528
0
    }
1529
0
    if let Some(actual_username) = actual_username.as_deref() {
1530
0
        client_args.extend(vec!["-u".to_string(), actual_username.to_string()]);
1531
0
    }
1532
0
    if let Some(port) = port {
1533
0
        client_args.extend(vec!["-p".to_string(), port.to_string()]);
1534
0
    }
1535
0
    client_args.push("client".to_string());
1536
0
    client_args.extend(vec!["--".to_string(), actual_host.to_string()]);
1537
1538
0
    let process_info = spawn_console_process(windows_api, &current_exe_path(), client_args, false)
1539
0
        .expect("Failed to create process");
1540
0
    let client_window_handle = get_console_window_handle(windows_api, process_info.dwProcessId);
1541
0
    let process_handle = windows_api
1542
0
        .open_process(PROCESS_QUERY_INFORMATION.0, false, process_info.dwProcessId)
1543
0
        .unwrap_or_else(|err| {
1544
0
            panic!(
1545
                "Failed to open process handle for process {}: {}",
1546
                process_info.dwProcessId, err
1547
            );
1548
        });
1549
1550
0
    arrange_client_window(
1551
0
        windows_api,
1552
0
        &client_window_handle,
1553
0
        workspace_area,
1554
0
        index,
1555
0
        number_of_consoles,
1556
0
        aspect_ratio_adjustment,
1557
    );
1558
0
    return (
1559
0
        client_window_handle,
1560
0
        process_handle,
1561
0
        process_info.dwProcessId,
1562
0
    );
1563
0
}
1564
1565
/// Correlate the connecting client by PID, then multiplex input records,
1566
/// [`ClientState`] updates, and keep-alives onto the named pipe.
1567
///
1568
/// The post-subscribe initial-state push is intentional: `state_receiver.changed`
1569
/// only fires on transitions observed *after* `subscribe`, so a state set
1570
/// in the brief window between [`Client`] construction and `subscribe`
1571
/// would otherwise leave the client on its default until the next
1572
/// transition.
1573
///
1574
/// The `select!` is biased toward `recv` so the keep-alive tick never
1575
/// preempts active input traffic; the [`ClientState::Disabled`] arm
1576
/// therefore probes the pipe itself, otherwise sustained input would
1577
/// hide a disconnect.
1578
///
1579
/// # Errors and termination
1580
///
1581
/// An unknown PID exits the process (production) or panics (tests) -
1582
/// the daemon's bookkeeping is broken and recovery is not possible.
1583
/// A failed pipe write or a dropped [`watch::Sender`] ends the routine
1584
/// cleanly.
1585
8
async fn named_pipe_server_routine(
1586
8
    server: NamedPipeServer,
1587
8
    receiver: &mut Receiver<[u8; SERIALIZED_INPUT_RECORD_0_LENGTH]>,
1588
8
    clients: Arc<Mutex<Clients>>,
1589
8
) {
1590
    // wait for a client to connect
1591
8
    server.connect().await.unwrap_or_else(|err| 
{0
1592
0
        error!("{}", err);
1593
0
        panic!("Timed out waiting for clients to connect to named pipe server",)
1594
    });
1595
1596
    // Correlate the connecting client by reading its 4 byte PID.
1597
8
    let 
pid7
= read_client_pid(&server).await;
1598
7
    let (
mut state_receiver6
,
mut highlight_receiver6
) = match clients.lock().unwrap().get_by_pid(pid)
1599
    {
1600
6
        Some(client) => (
1601
6
            client.state_sender.subscribe(),
1602
6
            client.highlight_sender.subscribe(),
1603
6
        ),
1604
        None => {
1605
1
            error!(
1606
                "Named pipe server received unknown PID {} - daemon bookkeeping broken",
1607
                pid
1608
            );
1609
            // In production this exits the daemon; in tests process::exit would kill
1610
            // the test runner, so we panic instead so tokio::spawn can catch it.
1611
            #[cfg(not(test))]
1612
            std::process::exit(1);
1613
            #[cfg(test)]
1614
1
            panic!("Unknown client PID {} - daemon bookkeeping broken", pid);
1615
        }
1616
    };
1617
1618
    // Initial state push - see fn docs.
1619
6
    let initial_state = *state_receiver.borrow_and_update();
1620
6
    let initial_state_frame: [u8; FRAMED_STATE_CHANGE_LENGTH] =
1621
6
        [TAG_STATE_CHANGE, serialize_client_state(initial_state)];
1622
6
    if !write_framed_message(&server, &initial_state_frame).await {
1623
0
        return;
1624
6
    }
1625
1626
    // Initial highlight push - same rationale as the state push above.
1627
6
    let initial_highlight = *highlight_receiver.borrow_and_update();
1628
6
    let initial_highlight_frame: [u8; FRAMED_HIGHLIGHT_LENGTH] =
1629
6
        [TAG_HIGHLIGHT, serialize_highlight(initial_highlight)];
1630
6
    if !write_framed_message(&server, &initial_highlight_frame).await {
1631
0
        return;
1632
6
    }
1633
1634
    loop {
1635
        // Independent watch channels: `state_receiver` and `highlight_receiver` are forwarded over the pipe in whichever order this `select!` happens to pick them up, not the order the daemon-side senders fired.
1636
24
        tokio::select! {
1637
            biased;
1638
24
            
recv_result16
= receiver.recv() => {
1639
14
                let ser_input_record = match 
recv_result2
{
1640
14
                    Ok(val) => val,
1641
1
                    Err(RecvError::Lagged(skipped)) => {
1642
                        // Slow consumers (typically disabled clients) drop
1643
                        // records rather than kill the routine; debug-level
1644
                        // because this can fire repeatedly under load.
1645
1
                        debug!(
1646
                            "Named pipe server routine lagged behind broadcast channel - dropping {} record(s)",
1647
                            skipped
1648
                        );
1649
                        // Probe and yield so sustained lag cannot starve
1650
                        // the keep-alive tick (the `select!` is `biased`
1651
                        // toward `recv`) and so a closed pipe is still
1652
                        // detected promptly under load.
1653
1
                        if !probe_pipe_alive(&server) {
1654
0
                            return;
1655
1
                        }
1656
1
                        tokio::task::yield_now().await;
1657
1
                        continue;
1658
                    }
1659
                    Err(RecvError::Closed) => {
1660
1
                        error!("Broadcast channel closed");
1661
1
                        panic!("Failed to receive data from the Receiver");
1662
                    }
1663
                };
1664
                // Copy out before any `.await` - `watch::Ref` is not `Send`.
1665
14
                let current_state = *state_receiver.borrow();
1666
14
                match current_state {
1667
7
                    ClientState::Active => {}
1668
                    ClientState::Disabled => {
1669
                        // Probe the pipe so a disabled client cannot hide a
1670
                        // disconnect under sustained input - the keep-alive
1671
                        // tick is starved while recv keeps yielding records.
1672
7
                        if !probe_pipe_alive(&server) {
1673
0
                            return;
1674
7
                        }
1675
7
                        tokio::task::yield_now().await;
1676
7
                        continue;
1677
                    }
1678
                }
1679
7
                let mut frame = [0u8; FRAMED_INPUT_RECORD_LENGTH];
1680
7
                frame[0] = TAG_INPUT_RECORD;
1681
7
                frame[1..].copy_from_slice(&ser_input_record);
1682
7
                if !write_framed_message(&server, &frame).await {
1683
0
                    return;
1684
7
                }
1685
            }
1686
24
            
changed_result2
= state_receiver.changed() => {
1687
                // Sender dropped - the daemon has removed this client from its
1688
                // bookkeeping, so there is nothing left to forward.
1689
2
                if changed_result.is_err() {
1690
0
                    debug!(
1691
                        "Client state sender dropped, stopping named pipe server routine ({:?})",
1692
                        server
1693
                    );
1694
0
                    return;
1695
2
                }
1696
2
                let state = *state_receiver.borrow_and_update();
1697
2
                let frame: [u8; FRAMED_STATE_CHANGE_LENGTH] =
1698
2
                    [TAG_STATE_CHANGE, serialize_client_state(state)];
1699
2
                if !write_framed_message(&server, &frame).await {
1700
0
                    return;
1701
2
                }
1702
            }
1703
24
            
changed_result0
= highlight_receiver.changed() => {
1704
                // Sender dropped - same rationale as the `state_receiver` arm.
1705
0
                if changed_result.is_err() {
1706
0
                    debug!(
1707
                        "Client highlight sender dropped, stopping named pipe server routine ({:?})",
1708
                        server
1709
                    );
1710
0
                    return;
1711
0
                }
1712
0
                let highlighted = *highlight_receiver.borrow_and_update();
1713
0
                let frame: [u8; FRAMED_HIGHLIGHT_LENGTH] =
1714
0
                    [TAG_HIGHLIGHT, serialize_highlight(highlighted)];
1715
0
                if !write_framed_message(&server, &frame).await {
1716
0
                    return;
1717
0
                }
1718
            }
1719
24
            _ = tokio::time::sleep(Duration::from_millis(5)) => {
1720
6
                if !write_framed_message(&server, &[TAG_KEEP_ALIVE]).await {
1721
5
                    return;
1722
1
                }
1723
            }
1724
        }
1725
    }
1726
5
}
1727
1728
/// Best-effort, non-blocking probe of the named pipe.
1729
///
1730
/// Returns `true` if a single `TAG_KEEP_ALIVE` byte either wrote
1731
/// successfully or returned `WouldBlock` (the pipe is still open but
1732
/// the OS buffer is full); `false` if any other error indicates the
1733
/// pipe is closed.
1734
8
fn probe_pipe_alive(server: &NamedPipeServer) -> bool {
1735
8
    match server.try_write(&[TAG_KEEP_ALIVE]) {
1736
7
        Ok(_) => return true,
1737
1
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => return true,
1738
        Err(_) => {
1739
0
            debug!(
1740
                "Named pipe server ({:?}) is closed, stopping named pipe server routine",
1741
                server
1742
            );
1743
0
            return false;
1744
        }
1745
    }
1746
8
}
1747
1748
/// Write all of `frame` to the named pipe server, retrying partial
1749
/// writes and `WouldBlock` results until the buffer is fully drained.
1750
///
1751
/// Returns `true` on full write, `false` if the pipe is closed.
1752
///
1753
/// # Panics
1754
///
1755
/// Panics if waiting for the pipe to become writable returns an error.
1756
27
async fn write_framed_message(server: &NamedPipeServer, frame: &[u8]) -> bool {
1757
27
    let mut written = 0usize;
1758
63
    while written < frame.len() {
1759
41
        server.writable().await.unwrap_or_else(|err| 
{0
1760
0
            error!("{}", err);
1761
0
            panic!("Timed out waiting for named pipe server to become writable",)
1762
        });
1763
41
        match server.try_write(&frame[written..]) {
1764
22
            Ok(n) => {
1765
22
                written += n;
1766
22
                if written < frame.len() {
1767
0
                    warn!(
1768
                        "Partially written data, expected {} but only wrote {} so far",
1769
0
                        frame.len(),
1770
                        written
1771
                    );
1772
22
                }
1773
            }
1774
19
            Err(
e14
) if e.kind() == io::ErrorKind::WouldBloc
k14
=> {
1775
                // Try again
1776
14
                debug!("Writing to named pipe server would have blocked");
1777
14
                continue;
1778
            }
1779
            Err(_) => {
1780
                // Can happen if the pipe is closed because the
1781
                // client exited
1782
5
                debug!(
1783
                    "Named pipe server ({:?}) is closed, stopping named pipe server routine",
1784
                    server
1785
                );
1786
5
                return false;
1787
            }
1788
        }
1789
    }
1790
22
    debug!("Successfully written all data");
1791
22
    return true;
1792
27
}
1793
1794
/// Read the connecting client's 4 byte little-endian process id from the pipe.
1795
///
1796
/// Reads exactly 4 bytes from `server`, retrying on `WouldBlock`, and decodes
1797
/// them as a `u32`. Any non-recoverable I/O error panics, as a client that
1798
/// cannot send its PID cannot be correlated and forwarding would be
1799
/// impossible.
1800
///
1801
/// # Arguments
1802
///
1803
/// * `server` - The connected named pipe server to read from.
1804
///
1805
/// # Returns
1806
///
1807
/// The process id sent by the client.
1808
///
1809
/// # Panics
1810
///
1811
/// Panics if the pipe is closed before 4 bytes can be read, or if any
1812
/// non-`WouldBlock` I/O error occurs.
1813
8
async fn read_client_pid(server: &NamedPipeServer) -> u32 {
1814
8
    let mut buf = [0u8; SERIALIZED_PID_LENGTH];
1815
8
    let mut read = 0usize;
1816
15
    while read < SERIALIZED_PID_LENGTH {
1817
8
        server.readable().await.unwrap_or_else(|err| 
{0
1818
0
            panic!("Named pipe server is not readable for PID handshake: {err}")
1819
        });
1820
8
        match server.try_read(&mut buf[read..]) {
1821
            Ok(0) => {
1822
1
                panic!("Named pipe server closed before PID handshake completed");
1823
            }
1824
7
            Ok(n) => {
1825
7
                read += n;
1826
7
            }
1827
0
            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
1828
0
                continue;
1829
            }
1830
0
            Err(e) => {
1831
0
                panic!("Failed to read PID from named pipe client: {e}");
1832
            }
1833
        }
1834
    }
1835
7
    return deserialize_pid(&buf);
1836
7
}
1837
1838
/// Re-sizes and re-positions the given client window based on the total number of clients,
1839
/// the size of the daemon console window and its index relative to the other client windows.
1840
///
1841
/// # Arguments
1842
///
1843
/// * `windows_api`              - The Windows API implementation to use
1844
/// * `handle`                   - Reference the windows handle of a client console window.
1845
/// * `workspace_area`           - The available workspace area on the primary monitor
1846
///                                minus the space occupied by the daemon console window.
1847
/// * `index`                    - The index of the client in the list of all clients.
1848
/// * `number_of_consoles`       - The total number of active client console windows.
1849
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1850
0
fn arrange_client_window<W: WindowsApi>(
1851
0
    windows_api: &W,
1852
0
    handle: &HWND,
1853
0
    workspace_area: &workspace::WorkspaceArea,
1854
0
    index: usize,
1855
0
    number_of_consoles: usize,
1856
0
    aspect_ratio_adjustment: f64,
1857
0
) {
1858
0
    let (x, y, width, height) = determine_client_spatial_attributes(
1859
0
        index as i32,
1860
0
        number_of_consoles as i32,
1861
0
        workspace_area,
1862
0
        aspect_ratio_adjustment,
1863
0
    );
1864
    // Since windows update 10.0.19041.5072 it can happen that a client windows rendering is broken
1865
    // after a move+resize. Why is unclear, but resizing again does solve the issue.
1866
    // We first make the window 1 pixel in each dimension too small and imediately fix it.
1867
    // To reduce overhead we do not repaint the window the first time.
1868
0
    windows_api
1869
0
        .move_window(*handle, x, y, width - 1, height - 1, false)
1870
0
        .unwrap_or_else(|err| {
1871
0
            error!("{}", err);
1872
0
            panic!("Failed to move window",)
1873
        });
1874
0
    windows_api
1875
0
        .move_window(*handle, x, y, width, height, true)
1876
0
        .unwrap_or_else(|err| {
1877
0
            error!("{}", err);
1878
0
            panic!("Failed to move window",)
1879
        });
1880
0
}
1881
1882
/// Return the workspace area's aspect ratio (width / height) including
1883
/// the frame padding the tiler accounts for.
1884
///
1885
/// # Arguments
1886
///
1887
/// * `workspace_area` - Available workspace minus the daemon console.
1888
///
1889
/// # Returns
1890
///
1891
/// Aspect ratio as a `f64` for use by both the tiler and the navigation
1892
/// grid.
1893
3
fn workspace_aspect_ratio(workspace_area: &workspace::WorkspaceArea) -> f64 {
1894
3
    return (workspace_area.width + (workspace_area.x_fixed_frame + workspace_area.x_size_frame) * 2)
1895
3
        as f64
1896
3
        / (workspace_area.height + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * 2)
1897
3
            as f64;
1898
3
}
1899
1900
/// Calculates the position and dimensions for a client window given its index,
1901
/// the total number of clients and the `aspect_ratio_adjustment` daemon configuration.
1902
///
1903
/// # Arguments
1904
///
1905
/// * `index`                    - The index of the client in the list of all clients.
1906
/// * `number_of_consoles`       - The total number of active client console windows.
1907
/// * `workspace_area`           - The available workspace area on the primary monitor
1908
///                                minus the space occupied by the daemon console window.
1909
/// * `aspect_ratio_adjustment` - The `aspect_ratio_adjustment` daemon configuration.
1910
///     * `> 0.0` - Aims for vertical rectangle shape.
1911
///       The larger the value, the more exaggerated the "verticality".
1912
///       Eventually the windows will all be columns.
1913
///     * `= 0.0` - Aims for square shape.
1914
///     * `< 0.0` - Aims for horizontal rectangle shape.
1915
///       The smaller the value, the more exaggerated the "horizontality".
1916
///       Eventually the windows will all be rows.
1917
///       `-1.0` is the sweetspot for mostly preserving a 16:9 ratio.
1918
0
fn determine_client_spatial_attributes(
1919
0
    index: i32,
1920
0
    number_of_consoles: i32,
1921
0
    workspace_area: &workspace::WorkspaceArea,
1922
0
    aspect_ratio_adjustment: f64,
1923
0
) -> (i32, i32, i32, i32) {
1924
0
    let aspect_ratio = workspace_aspect_ratio(workspace_area);
1925
0
    let (grid_columns, grid_rows) =
1926
0
        grid_dimensions(number_of_consoles, aspect_ratio, aspect_ratio_adjustment);
1927
1928
0
    let grid_column_index = index % grid_columns;
1929
0
    let grid_row_index = index / grid_columns;
1930
1931
0
    let is_last_row = grid_row_index == grid_rows - 1;
1932
0
    let last_row_console_count = number_of_consoles % grid_columns;
1933
1934
0
    let console_width = if is_last_row && last_row_console_count != 0 {
1935
0
        (workspace_area.width / last_row_console_count)
1936
0
            + if last_row_console_count > 1 {
1937
0
                workspace_area.x_fixed_frame + workspace_area.x_size_frame
1938
            } else {
1939
0
                0
1940
            }
1941
    } else {
1942
0
        (workspace_area.width / grid_columns)
1943
0
            + (workspace_area.x_fixed_frame + workspace_area.x_size_frame)
1944
    };
1945
1946
0
    let console_height = (workspace_area.height
1947
0
        + (workspace_area.y_fixed_frame + workspace_area.y_size_frame) * grid_row_index)
1948
0
        / grid_rows;
1949
1950
0
    let x = grid_column_index * console_width
1951
0
        - ((workspace_area.x_fixed_frame + workspace_area.x_size_frame) * (grid_column_index + 1));
1952
0
    let y = grid_row_index * console_height
1953
0
        - ((workspace_area.y_fixed_frame + workspace_area.y_size_frame) * (grid_row_index - 1));
1954
1955
0
    return get_console_rect(x, y, console_width, console_height, workspace_area);
1956
0
}
1957
1958
/// Transform the position and dimensions of a console window based
1959
/// on the workspace area.
1960
///
1961
/// To minimize empty space between windows, width and height must be adjusted
1962
/// by the `fixed_frame` and `size_frame` values.
1963
///
1964
/// # Arguments
1965
///
1966
/// * `x`              - The `x` coordinate of the window.
1967
/// * `y`              - The `y` coordinate of the window.
1968
/// * `width`          - The `width` in pixels of the window.
1969
/// * `height`         - The `height` in pixels of the window.
1970
/// * `workspace_area` - The available workspace area on the primary monitor minus
1971
///                      the space occupied by the daemon console window.
1972
///
1973
/// # Returns
1974
///
1975
/// (`x`, `y`, `width`, `height`)
1976
///
1977
0
fn get_console_rect(
1978
0
    x: i32,
1979
0
    y: i32,
1980
0
    width: i32,
1981
0
    height: i32,
1982
0
    workspace_area: &workspace::WorkspaceArea,
1983
0
) -> (i32, i32, i32, i32) {
1984
0
    return (
1985
0
        std::cmp::max(
1986
0
            workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame),
1987
0
            workspace_area.x - (workspace_area.x_fixed_frame + workspace_area.x_size_frame) + x,
1988
0
        ),
1989
0
        workspace_area.y - (workspace_area.y_fixed_frame + workspace_area.y_size_frame) + y,
1990
0
        std::cmp::min(workspace_area.width, width),
1991
0
        height,
1992
0
    );
1993
0
}
1994
1995
/// Spawns a background thread that ensures the z-order of all client
1996
/// windows is in sync with the daemon window.
1997
/// I.e. if the daemon window is focussed, all clients should be moved to the foreground.
1998
///
1999
/// # Arguments
2000
///
2001
/// * `windows_api` - Arc-wrapped Windows API implementation for thread-safe access
2002
/// * `clients`     - A thread safe mapping from the number
2003
///                   a client console window was launched at
2004
///                   in relation to the other client windows
2005
///                   and the clients console window handle.
2006
///                   The mapping must be thread safe to allow
2007
///                   it to be modified by the main thread
2008
///                   while we periodically read from it in the
2009
///                   background thread.
2010
0
fn ensure_client_z_order_in_sync_with_daemon<W: WindowsApi + Send + Sync + 'static>(
2011
0
    windows_api: Arc<W>,
2012
0
    clients: Arc<Mutex<Clients>>,
2013
0
) {
2014
0
    tokio::spawn(async move {
2015
0
        let daemon_handle = get_console_window_wrapper(windows_api.as_ref());
2016
0
        let mut previous_foreground_window = get_foreground_window_wrapper(windows_api.as_ref());
2017
        loop {
2018
0
            tokio::time::sleep(Duration::from_millis(1)).await;
2019
0
            let foreground_window = get_foreground_window_wrapper(windows_api.as_ref());
2020
0
            if previous_foreground_window == foreground_window {
2021
0
                continue;
2022
0
            }
2023
0
            if foreground_window == daemon_handle
2024
0
                && !clients.lock().unwrap().iter().any(|client| {
2025
0
                    return client.window_handle == previous_foreground_window.hwdn
2026
0
                        || client.window_handle == daemon_handle.hwdn;
2027
0
                })
2028
0
            {
2029
0
                defer_windows(
2030
0
                    windows_api.as_ref(),
2031
0
                    &clients.lock().unwrap(),
2032
0
                    &daemon_handle.hwdn,
2033
0
                );
2034
0
            }
2035
0
            previous_foreground_window = foreground_window;
2036
        }
2037
    });
2038
0
}
2039
2040
/// Move all given windows to the foreground.
2041
///
2042
/// Restores minimized windows.
2043
/// If a window handle no longer points to a valid window, it is skipped.
2044
/// The daemon window is deferred last and receives focus.
2045
///
2046
/// # Arguments
2047
///
2048
/// * `windows_api`                   - The Windows API implementation to use
2049
/// * `clients`                       - A thread safe mapping from the number
2050
///                                     a client console window was launched at
2051
///                                     in relation to the other client windows
2052
///                                     and the clients console window handle.
2053
/// * `daemon_handle`                 - Handle to the daemon console window.
2054
0
fn defer_windows<W: WindowsApi>(windows_api: &W, clients: &[Client], daemon_handle: &HWND) {
2055
0
    for client in clients.iter() {
2056
0
        restore_if_minimized(windows_api, client.window_handle, false);
2057
0
        let _ = windows_api.bring_window_to_top(client.window_handle, false);
2058
0
    }
2059
    // Raise the daemon last so it ends up on top and keeps keyboard focus.
2060
0
    restore_if_minimized(windows_api, *daemon_handle, true);
2061
0
    let _ = windows_api.bring_window_to_top(*daemon_handle, true);
2062
0
}
2063
2064
/// Restore `window_handle` if its current placement reports minimized.
2065
///
2066
/// Silently does nothing when the placement query fails or the window is
2067
/// not minimized. Used by [`defer_windows`] so both client and daemon
2068
/// windows are brought back from the taskbar before z-order updates.
2069
///
2070
/// # Arguments
2071
///
2072
/// * `windows_api`         - Windows API implementation.
2073
/// * `window_handle`       - Handle to the window to potentially restore.
2074
/// * `with_keyboard_focus` - Whether the restored window should be activated.
2075
///                           Pass `false` for client windows so unminimizing
2076
///                           them does not steal foreground from the daemon -
2077
///                           `SW_RESTORE` activates, which would let the
2078
///                           last-restored client win the foreground race and
2079
///                           block [`WindowsApi::bring_window_to_top`] from
2080
///                           refocusing the daemon.
2081
0
fn restore_if_minimized<W: WindowsApi>(
2082
0
    windows_api: &W,
2083
0
    window_handle: HWND,
2084
0
    with_keyboard_focus: bool,
2085
0
) {
2086
0
    let placement = match windows_api.get_window_placement(window_handle) {
2087
0
        Ok(placement) => placement,
2088
0
        Err(_) => return,
2089
    };
2090
0
    if placement.showCmd == SW_SHOWMINIMIZED.0.try_into().unwrap() {
2091
0
        let cmd = if with_keyboard_focus {
2092
0
            SW_RESTORE
2093
        } else {
2094
0
            SW_SHOWNOACTIVATE
2095
        };
2096
0
        let _ = windows_api.show_window(window_handle, cmd);
2097
0
    }
2098
0
}
2099
2100
/// The entrypoint for the `daemon` subcommand.
2101
///
2102
/// Spawns 1 client process with its own window for each host
2103
/// and 1 worker thread that handles communication with the client
2104
/// over a named pipe.
2105
/// Responsible for client window positioning and sizing.
2106
/// Handles control mode.
2107
/// Main thread reads input records from the console input buffer
2108
/// and propagates them via the background threads to all clients
2109
/// simultaneously.
2110
///
2111
/// # Arguments
2112
///
2113
/// * `windows_api` - The Windows API implementation to use
2114
/// * `hosts`    - List of hostnames for which to launch clients.
2115
/// * `username` - Username used to connect to the hosts.
2116
///                If none, each client will use the SSH config to determine
2117
///                a suitable username for their respective host.
2118
/// * `port`     - Optional port used for all SSH connections.
2119
/// * `config`   - The `DaemonConfig`.
2120
/// * `debug`    - Enables debug logging
2121
0
pub async fn main<W: WindowsApi + Clone + 'static>(
2122
0
    windows_api: &W,
2123
0
    hosts: Vec<String>,
2124
0
    username: Option<String>,
2125
0
    port: Option<u16>,
2126
0
    config: &DaemonConfig,
2127
0
    clusters: &[Cluster],
2128
0
    debug: bool,
2129
0
) {
2130
0
    let daemon: Daemon = Daemon {
2131
0
        hosts: explode(&hosts.join(" ")).unwrap_or(hosts),
2132
0
        username,
2133
0
        port,
2134
0
        config,
2135
0
        clusters,
2136
0
        control_mode_state: ControlModeState::Inactive,
2137
0
        debug,
2138
0
    };
2139
0
    daemon.launch(windows_api).await;
2140
0
    debug!("Actually exiting");
2141
0
}
2142
2143
#[cfg(test)]
2144
#[path = "../tests/daemon/test_mod.rs"]
2145
mod test_mod;